/*******************************************************************************
 * Copyright (c) 2001, 2011 IBM Corporation and others. All rights reserved. This program and the
 * accompanying materials are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html Contributors: IBM Corporation - initial API and
 * implementation Jens Lukowski/Innoopract - initial renaming/restructuring
 *******************************************************************************/
package org.eclipse.wst.sse.ui.internal.reconcile;

import org.eclipse.core.runtime.Platform;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.DocumentEvent;
import org.eclipse.jface.text.DocumentRewriteSessionEvent;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IDocumentExtension3;
import org.eclipse.jface.text.IDocumentExtension4;
import org.eclipse.jface.text.IDocumentListener;
import org.eclipse.jface.text.IDocumentRewriteSessionListener;
import org.eclipse.jface.text.ITextInputListener;
import org.eclipse.jface.text.ITextViewer;
import org.eclipse.jface.text.ITypedRegion;
import org.eclipse.jface.text.TextUtilities;
import org.eclipse.jface.text.reconciler.DirtyRegion;
import org.eclipse.jface.text.reconciler.IReconciler;
import org.eclipse.jface.text.reconciler.IReconcilerExtension;
import org.eclipse.jface.text.reconciler.IReconcilingStrategy;
import org.eclipse.wst.sse.ui.internal.Logger;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

/**
 * This Job holds a queue of updates from the editor (DirtyRegions) to process. When a new request
 * comes in, the current run is canceled, the new request is added to the queue, then the job is
 * re-scheduled.
 * 
 * @author pavery
 */
public class DirtyRegionProcessor implements IReconciler, IReconcilerExtension,
    IConfigurableReconciler {
  class DocumentListener implements IDocumentListener {
    public void documentAboutToBeChanged(DocumentEvent event) {
      /*
       * if in rewrite session and already going to reprocess entire document after rewrite session,
       * do nothing
       */
      if (isInRewriteSession() && fReprocessAfterRewrite)
        return;
      // save partition type (to see if it changes in documentChanged())
      fLastPartitions = getPartitionRegions(event.getOffset(), event.getLength());
    }

    public void documentChanged(DocumentEvent event) {
      /*
       * if in rewrite session and already going to reprocess entire document after rewrite session,
       * do nothing
       */
      if (isInRewriteSession() && fReprocessAfterRewrite)
        return;

      if (partitionsChanged(event)) {
        // pa_TODO
        // this is a simple way to ensure old
        // annotations are removed when partition changes

        // it might be a performance hit though
        setEntireDocumentDirty(getDocument());
      } else {
        /*
         * Note that creating DirtyRegions *now* means that the wrong text may be included
         */
        DirtyRegion dr = null;
        if (event.getLength() == 0) {
          /*
           * It's an insert-- we use text length though so that the new region gets validated...
           */
          dr = createDirtyRegion(event.getOffset(), 0, DirtyRegion.INSERT);
        } else {
          if ("".equals(event.getText())) { //$NON-NLS-1$
            // it's a delete
            dr = createDirtyRegion(event.getOffset(), event.getLength(), DirtyRegion.REMOVE);
          } else {
            // it's a replace
            dr = createDirtyRegion(event.getOffset(), event.getLength(), DirtyRegion.INSERT);
          }
        }
        if (isInRewriteSession()) {
          /*
           * while in rewrite session, found a dirty region, so flag that entire document needs to
           * be reprocesed after rewrite session
           */
          if (!fReprocessAfterRewrite && (dr != null)) {
            fReprocessAfterRewrite = true;
          }
        } else {
          processDirtyRegion(dr);
        }
      }
    }

    /**
     * Checks previous partitions from the span of the event w/ the new partitions from the span of
     * the event. If partitions changed, return true, else return false
     * 
     * @param event
     * @return
     */
    private boolean partitionsChanged(DocumentEvent event) {
      boolean changed = false;
      int length = event.getLength();

      if (event.getLength() == 0 && event.getText().length() > 0) {
        // it's an insert, we want partitions of the new text
        length = event.getText().length();
      }

      ITypedRegion[] newPartitions = getPartitionRegions(event.getOffset(), length);
      if (fLastPartitions != null) {
        if (fLastPartitions.length != newPartitions.length) {
          changed = true;
        } else {
          for (int i = 0; i < fLastPartitions.length; i++) {
            if (!fLastPartitions[i].getType().equals(newPartitions[i].getType())) {
              changed = true;
              break;
            }
          }
        }
      }
      return changed;
    }

  }

  /**
   * Reconciles the entire document when the document in the viewer is changed. This happens when
   * the document is initially opened, as well as after a save-as. Also see
   * processPostModelEvent(...) for similar behavior when document for the model is changed.
   */
  class TextInputListener implements ITextInputListener {
    public void inputDocumentAboutToBeChanged(IDocument oldInput, IDocument newInput) {
      // do nothing
    }

    public void inputDocumentChanged(IDocument oldInput, IDocument newInput) {
      handleInputDocumentChanged(oldInput, newInput);

      startReconciling();
    }
  }

  class DocumentRewriteSessionListener implements IDocumentRewriteSessionListener {
    long time0 = 0;

    public void documentRewriteSessionChanged(DocumentRewriteSessionEvent event) {
      boolean oldValue = fInRewriteSession;
      fInRewriteSession = event != null
          && event.getChangeType().equals(DocumentRewriteSessionEvent.SESSION_START);

      if (event.getChangeType().equals(DocumentRewriteSessionEvent.SESSION_START)) {
        if (DEBUG) {
          time0 = System.currentTimeMillis();
        }
        // bug 235446 - source validation annotations lost after rewrite session
        if (!getDirtyRegionQueue().isEmpty()) {
          flushDirtyRegionQueue();
          fReprocessAfterRewrite = true;
        } else {
          fReprocessAfterRewrite = false;
        }
      } else if (event.getChangeType().equals(DocumentRewriteSessionEvent.SESSION_STOP)) {
        if (fInRewriteSession ^ oldValue && fDocument != null) {
          if (DEBUG) {
            Logger.log(Logger.INFO, "Rewrite session lasted "
                + (System.currentTimeMillis() - time0) + "ms");
            time0 = System.currentTimeMillis();
          }
          if (fReprocessAfterRewrite) {
            DirtyRegion entireDocument = createDirtyRegion(0, fDocument.getLength(),
                DirtyRegion.INSERT);
            processDirtyRegion(entireDocument);
          }
          if (DEBUG) {
            Logger.log(Logger.INFO, "Full document reprocess took "
                + (System.currentTimeMillis() - time0) + "ms");
          }
          fReprocessAfterRewrite = false;
        }
      }
    }
  }

  /** debug flag */
  protected static final boolean DEBUG;
  private static final long UPDATE_DELAY = 500;

  static {
    String value = Platform.getDebugOption("org.eclipse.wst.sse.ui/debug/reconcilerjob"); //$NON-NLS-1$
    DEBUG = value != null && value.equalsIgnoreCase("true"); //$NON-NLS-1$
  }

  private long fDelay;

  /** local queue of dirty regions (created here) to be reconciled */
  private List fDirtyRegionQueue = Collections.synchronizedList(new ArrayList());

  /** document that this reconciler works on */
  private IDocument fDocument = null;

  private IDocumentListener fDocumentListener = new DocumentListener();
  private IDocumentRewriteSessionListener fDocumentRewriteSessionListener = new DocumentRewriteSessionListener();

  /**
   * set true after first install to prevent duplicate work done in the install method (since
   * install gets called multiple times)
   */
  private boolean fIsInstalled = false;

  /**
   * so we can tell if a partition changed after the last edit
   */
  ITypedRegion[] fLastPartitions;

  List fNonIncrementalStrategiesAlreadyProcessed = new ArrayList(1);

  /**
   * The partitioning this reconciler uses.
   */
  private String fPartitioning;

  Map fReconcilingStrategies = null;

  /** for initial reconcile when document is opened */
  private TextInputListener fTextInputListener = null;
  /** the text viewer */
  private ITextViewer fViewer;
  boolean fInRewriteSession = false;
  /**
   * true if entire document needs to be reprocessed after rewrite session
   */
  boolean fReprocessAfterRewrite = false;

  /** The job should be reset because of document changes */
  private boolean fReset = false;
  private boolean fIsCanceled = false;
  private boolean fHasReconciled = false;
  private Object LOCK = new Object();

  private BackgroundThread fThread;

  /**
   * Creates a new StructuredRegionProcessor
   */
  public DirtyRegionProcessor() {
    // init reconciler stuff
    setDelay(UPDATE_DELAY);
    fReconcilingStrategies = new HashMap();
  }

  /**
   * Adds the given resource to the set of resources that need refreshing. Synchronized in order to
   * protect the collection during add.
   * 
   * @param resource
   */
  private synchronized void addRequest(DirtyRegion newDirtyRegion) {
    // NOTE: This method is called a lot so make sure it's fast
    List dirtyRegionQueue = getDirtyRegionQueue();
    synchronized (dirtyRegionQueue) {
      for (Iterator it = dirtyRegionQueue.iterator(); it.hasNext();) {
        // go through list of existing dirty regions and check if any
        // dirty regions need to be discarded
        DirtyRegion currentExisting = (DirtyRegion) it.next();
        DirtyRegion outer = getOuterRegion(currentExisting, newDirtyRegion);
        // if we already have a request which contains the new request,
        // discard the new request
        if (outer == currentExisting)
          return;
        // if new request contains any existing requests,
        // remove those
        if (outer == newDirtyRegion)
          it.remove();
      }
      dirtyRegionQueue.add(newDirtyRegion);
    }
  }

  /**
   * Notifies subclasses that processing of multiple dirty regions has begun
   */
  protected void beginProcessing() {
    // do nothing by default
  }

  /**
   * @param dirtyRegion
   * @return
   */
  protected ITypedRegion[] computePartitioning(DirtyRegion dirtyRegion) {
    int drOffset = dirtyRegion.getOffset();
    int drLength = dirtyRegion.getLength();

    return computePartitioning(drOffset, drLength);
  }

  protected ITypedRegion[] computePartitioning(int drOffset, int drLength) {
    ITypedRegion[] tr = new ITypedRegion[0];
    IDocument doc = getDocument();
    if (doc != null) {
      int docLength = doc.getLength();

      if (drOffset > docLength) {
        drOffset = docLength;
        drLength = 0;
      } else if (drOffset + drLength > docLength) {
        drLength = docLength - drOffset;
      }

      try {
        // dirty region may span multiple partitions
        tr = TextUtilities.computePartitioning(doc, getDocumentPartitioning(), drOffset, drLength,
            true);
      } catch (BadLocationException e) {
        String info = "dr: [" + drOffset + ":" + drLength + "] doc: [" + docLength + "] "; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
        Logger.logException(info, e);
        tr = new ITypedRegion[0];
      }
    }
    return tr;
  }

  /**
   * Used to determine if one dirty region contains the other and if so, which is the one that
   * contains it.
   * 
   * @param root
   * @param possible
   * @return the outer dirty region if it contains the other dirty region, null otherwise
   */
  protected DirtyRegion getOuterRegion(DirtyRegion root, DirtyRegion possible) {
    DirtyRegion outer = null;
    if (isContained(root, possible))
      outer = root;
    else if (isContained(possible, root))
      outer = possible;
    return outer;
  }

  /**
   * Used to determine of a "possible" dirty region can be discarded in favor of using just the
   * "root" dirty region.
   * 
   * @return if the root dirty region contains possible, return true, otherwise return false
   */
  private boolean isContained(DirtyRegion root, DirtyRegion possible) {
    int rootStart = root.getOffset();
    int rootEnd = rootStart + root.getLength();
    int possStart = possible.getOffset();
    int possEnd = possStart + possible.getLength();
    if (rootStart <= possStart && rootEnd >= possEnd)
      return true;
    return false;
  }

  protected DirtyRegion createDirtyRegion(int offset, int length, String type) {
    DirtyRegion durty = null;
    IDocument doc = getDocument();

    if (doc != null) {
      // safety for BLE
      int docLen = doc.getLength();
      if (offset > docLen) {
        offset = docLen;
        length = 0;
      } else if (offset + length >= docLen)
        length = docLen - offset;
      try {
        durty = new DirtyRegion(offset, length, type, doc.get(offset, length));
      } catch (BadLocationException e) {
        String info = "dr: [" + offset + ":" + length + "] doc: [" + docLen + "] "; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
        Logger.logException(info, e);
      }
    }
    return durty;
  }

  protected DirtyRegion createDirtyRegion(ITypedRegion tr, String type) {
    return createDirtyRegion(tr.getOffset(), tr.getLength(), type);
  }

  protected void flushDirtyRegionQueue() {
    synchronized (fDirtyRegionQueue) {
      fDirtyRegionQueue.clear();
    }
  }

  /**
   * Notifies subclasses that processing of multiple dirty regions has ended, for now
   */
  protected void endProcessing() {
    // do nothing by default
  }

  /**
   * Delay between processing of DirtyRegions.
   * 
   * @return
   */
  long getDelay() {
    return fDelay;
  }

  List getDirtyRegionQueue() {
    return fDirtyRegionQueue;
  }

  /**
   * The IDocument on which this reconciler operates
   * 
   * @return
   */
  protected IDocument getDocument() {
    return fDocument;
  }

  public String getDocumentPartitioning() {
    if (fPartitioning == null)
      return IDocumentExtension3.DEFAULT_PARTITIONING;
    return fPartitioning;
  }

  protected String[] getPartitions(int drOffset, int drLength) {

    ITypedRegion[] regions = new ITypedRegion[0];
    int docLength = getDocument().getLength();

    if (drOffset > docLength) {
      drOffset = docLength;
      drLength = 0;
    } else if (drOffset + drLength > docLength) {
      drLength = docLength - drOffset;
    }

    try {
      regions = TextUtilities.computePartitioning(getDocument(), getDocumentPartitioning(),
          drOffset, drLength, true);
    } catch (BadLocationException e) {
      Logger.logException(e);
      regions = new ITypedRegion[0];
    }
    String[] partitions = new String[regions.length];
    for (int i = 0; i < regions.length; i++)
      partitions[i] = regions[i].getType();
    return partitions;
  }

  ITypedRegion[] getPartitionRegions(int drOffset, int drLength) {
    ITypedRegion[] regions = new ITypedRegion[0];
    int docLength = getDocument().getLength();

    if (drOffset > docLength) {
      drOffset = docLength;
      drLength = 0;
    } else if (drOffset + drLength > docLength) {
      drLength = docLength - drOffset;
    }

    try {
      regions = TextUtilities.computePartitioning(getDocument(), getDocumentPartitioning(),
          drOffset, drLength, true);
    } catch (BadLocationException e) {
      Logger.logException(e);
      regions = new ITypedRegion[0];
    }
    return regions;
  }

  /**
   * Returns the reconciling strategy registered with the reconciler for the specified partition
   * type.
   * 
   * @param partitionType the partition type for which to determine the reconciling strategy
   * @return the reconciling strategy registered for the given partition type, or <code>null</code>
   *         if there is no such strategy
   * @see org.eclipse.jface.text.reconciler.IReconciler#getReconcilingStrategy(java.lang.String)
   */
  public IReconcilingStrategy getReconcilingStrategy(String partitionType) {
    if (partitionType == null)
      return null;
    return (IReconcilingStrategy) fReconcilingStrategies.get(partitionType);
  }

  /**
   * This method also synchronized because it accesses the fRequests queue
   * 
   * @return an array of the currently requested Nodes to refresh
   */
  private synchronized DirtyRegion[] getRequests() {
    synchronized (fDirtyRegionQueue) {
      if (0 == fDirtyRegionQueue.size()) {
        return null;
      }
      DirtyRegion[] toRefresh = (DirtyRegion[]) fDirtyRegionQueue.toArray(new DirtyRegion[fDirtyRegionQueue.size()]);
      flushDirtyRegionQueue();
      return toRefresh;
    }
  }

  /**
   * Returns the text viewer this reconciler is installed on.
   * 
   * @return the text viewer this reconciler is installed on
   */
  protected ITextViewer getTextViewer() {
    return fViewer;
  }

  /**
   * @param oldInput
   * @param newInput
   */
  void handleInputDocumentChanged(IDocument oldInput, IDocument newInput) {
    // don't bother if reconciler not installed
    if (isInstalled()) {
      setDocument(newInput);
    }
  }

  /**
   * @see org.eclipse.jface.text.reconciler.IReconciler#install(ITextViewer)
   */
  public void install(ITextViewer textViewer) {
    // we might be called multiple times with the same viewe.r,
    // maybe after being uninstalled as well, so track separately
    if (!isInstalled()) {
      fViewer = textViewer;
      fTextInputListener = new TextInputListener();
      textViewer.addTextInputListener(fTextInputListener);
      setInstalled(true);
    }
    synchronized (this) {
      if (fThread == null)
        fThread = new BackgroundThread(getClass().getName());
    }
  }

  /**
   * The viewer has been set on this Reconciler.
   * 
   * @return true if the viewer has been set on this Reconciler, false otherwise.
   */
  public boolean isInstalled() {
    return fIsInstalled;
  }

  boolean isInRewriteSession() {
    return fInRewriteSession;
  }

  /**
   * Subclasses should implement for specific handling of dirty regions. The method is invoked for
   * each dirty region in the Job's queue.
   * 
   * @param dirtyRegion
   */
  protected void process(DirtyRegion dirtyRegion) {
    if (!isInstalled() || isInRewriteSession() || dirtyRegion == null || getDocument() == null
        || fIsCanceled) {
      return;
    }
    /*
     * Break the dirty region into a sequence of partitions and find the corresponding strategy to
     * reconcile those partitions. If a strategy implements INonIncrementalReconcilingStrategy, only
     * call it once regardless of the number and types of partitions.
     */
    ITypedRegion[] partitions = computePartitioning(dirtyRegion);
    for (int i = 0; i < partitions.length && !fIsCanceled; i++) {
      IReconcilingStrategy strategy = getReconcilingStrategy(partitions[i].getType());
      if (strategy != null) {
        strategy.reconcile(partitions[i]);
      }
    }
  }

  /**
   * Invoke dirty region processing.
   * 
   * @param node
   */
  public final void processDirtyRegion(DirtyRegion dr) {
    if (dr == null || !isInstalled())
      return;

    addRequest(dr);
    synchronized (LOCK) {
      fReset = true;
    }

    if (DEBUG) {
      System.out.println("added request for: [" + dr.getText() + "]"); //$NON-NLS-1$ //$NON-NLS-2$
      System.out.println("queue size is now: " + getDirtyRegionQueue().size()); //$NON-NLS-1$
    }
  }

  /**
   * Based on {@link org.eclipse.jface.text.reconciler.AbstractReconciler.BackgroundThread}
   */
  class BackgroundThread extends Thread {

    public BackgroundThread(String name) {
      super(name);
      setPriority(Thread.MIN_PRIORITY);
      setDaemon(true);
    }

    public void cancel() {
      synchronized (fDirtyRegionQueue) {
        fDirtyRegionQueue.notifyAll();
      }
    }

    public void run() {
      synchronized (fDirtyRegionQueue) {
        try {
          fDirtyRegionQueue.wait(getDelay());
        } catch (InterruptedException e) {
        }
      }

      if (fIsCanceled)
        return;

      if (!fHasReconciled) {
        initialReconcile();
        fHasReconciled = true;
      }

      while (!fIsCanceled) {
        synchronized (fDirtyRegionQueue) {
          try {
            fDirtyRegionQueue.wait(getDelay());
          } catch (InterruptedException e) {
          }
        }

        if (fIsCanceled)
          return;

        synchronized (LOCK) {
          if (fReset) {
            fReset = false;
            continue;
          }
        }

        boolean processed = false;
        try {
          DirtyRegion[] toRefresh = getRequests();
          if (toRefresh != null && toRefresh.length > 0) {
            processed = true;
            beginProcessing();

            for (int i = 0; i < toRefresh.length && fDocument != null; i++) {
              if (fIsCanceled)
                return;
              process(toRefresh[i]);
            }
          }
        } finally {
          if (processed)
            endProcessing();
        }

      }
    }
  }

  public void setDelay(long delay) {
    fDelay = delay;
  }

  public void setDocument(IDocument doc) {
    if (fDocument != null) {
      // unhook old document listener
      fDocument.removeDocumentListener(fDocumentListener);
      if (fDocument instanceof IDocumentExtension4) {
        ((IDocumentExtension4) fDocument).removeDocumentRewriteSessionListener(fDocumentRewriteSessionListener);
      }
    }

    fDocument = doc;

    if (fDocument != null) {
      if (fDocument instanceof IDocumentExtension4) {
        ((IDocumentExtension4) fDocument).addDocumentRewriteSessionListener(fDocumentRewriteSessionListener);
      }
      // hook up new document listener
      fDocument.addDocumentListener(fDocumentListener);

      setEntireDocumentDirty(doc);
    }
  }

  /**
   * This method is called before the initial reconciling of the document.
   */
  protected void initialReconcile() {
  }

  /**
   * Sets the document partitioning for this reconciler.
   * 
   * @param partitioning the document partitioning for this reconciler
   */
  public void setDocumentPartitioning(String partitioning) {
    fPartitioning = partitioning;
  }

  /**
   * Forces reconciling of the entire document.
   */
  protected void forceReconciling() {
    if (!fHasReconciled)
      return;

    setEntireDocumentDirty(getDocument());
  }

  /**
   * Basically means process the entire document.
   * 
   * @param document
   */
  protected void setEntireDocumentDirty(IDocument document) {

    // make the entire document dirty
    // this also happens on a "save as"
    if (document != null && isInstalled()) {

      // since we're marking the entire doc dirty
      flushDirtyRegionQueue();

      /**
       * https://bugs.eclipse.org/bugs/show_bug.cgi?id=199053 Process the strategies for the last
       * known-good partitions to ensure all problem annotations are cleared if needed.
       */
      if (fLastPartitions != null && document.getLength() == 0) {
        for (int i = 0; i < fLastPartitions.length; i++) {
          IReconcilingStrategy strategy = getReconcilingStrategy(fLastPartitions[i].getType());
          if (strategy != null) {
            strategy.reconcile(fLastPartitions[i]);
          }
        }
      } else {
        DirtyRegion entireDocument = createDirtyRegion(0, document.getLength(), DirtyRegion.INSERT);
        processDirtyRegion(entireDocument);
      }
    }
  }

  /**
   * @param isInstalled The isInstalled to set.
   */
  void setInstalled(boolean isInstalled) {
    fIsInstalled = isInstalled;
  }

  public void setReconcilingStrategy(String partitionType, IReconcilingStrategy strategy) {
    if (partitionType == null) {
      throw new IllegalArgumentException();
    }

    if (strategy == null) {
      fReconcilingStrategies.remove(partitionType);
    } else {
      fReconcilingStrategies.put(partitionType, strategy);
    }
  }

  /**
   * @see org.eclipse.jface.text.reconciler.IReconciler#uninstall()
   */
  public void uninstall() {
    if (isInstalled()) {
      // removes widget listener
      getTextViewer().removeTextInputListener(fTextInputListener);
      setInstalled(false);
      fIsCanceled = true;
      synchronized (this) {
        BackgroundThread bt = fThread;
        fThread = null;
        bt.cancel();
      }
    }
    synchronized (fDirtyRegionQueue) {
      fDirtyRegionQueue.clear();
    }
    setDocument(null);
  }

  protected synchronized void startReconciling() {
    if (fThread == null)
      return;
    if (!fThread.isAlive()) {
      try {
        fThread.start();
      } catch (IllegalThreadStateException e) {
      }
    }
  }
}
